feat(core): add masked_connector_response with per-connector key allowlist - #2050
feat(core): add masked_connector_response with per-connector key allowlist#2050shuklatushar226 wants to merge 15 commits into
Conversation
…lowlist
`raw_connector_response` is an `Option<Secret<String>>`, so `masked_serialize`
collapses the entire gateway reply into a single placeholder
(`*** alloc::string::String ***`). That leaves only two options: expose the whole
body, which carries PAN/CVV/tokens, or see nothing at all. Fields a transformer
never modelled are invisible either way, since `response.body` only ever shows
the typed struct.
Add a sibling field carrying the same body with every key preserved and every
value masked unless that connector's configured list names it. It is a plain
`String`, not a `Secret`, because it is already sanitized — wrapping it would
reproduce the bug being fixed.
- masking happens during serialization rather than by mutating the parsed tree.
A walk-and-overwrite pass cannot see scalars inside arrays (they arrive with no
key in scope), so `{"tags":["4111111111111111"]}` would leak. Carrying the
parent-key decision into elements makes that impossible by construction, and
drops a pass plus one allocation per masked field.
- output keeps the input format: JSON in/JSON out, XML in/XML out (copied event
by event so namespaces, attributes and ordering survive), form in/form out.
- config is per-connector only. A field name safe on one gateway is not
necessarily safe on another, so there is deliberately no global list. A
connector with no entry gets every value masked with every key still visible,
which is both the safe default and how you discover names to configure.
- keyed by `ConnectorEnum`, so an unknown connector name aborts startup naming
the bad key instead of silently masking everything. Keys parse via `FromStr`,
not the serde derive, because the config crate lowercases env-var keys.
- gated by its own `enabled` flag, independent of `return_raw_connector_data`,
so production can keep raw capture off while retaining the safe view. That is
the main reason the feature exists.
Verified against the Adyen sandbox: listed fields visible, all 51
`additionalData` keys present with masked values, no PAN or CVC anywhere in the
logs, and the field still populated on the 4xx path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops the test module and the `toml` dev-dependency it required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The emitted body is now returned whole. `max_bytes` carried the cap value in two places — the TOML files and a `DEFAULT_MAX_BYTES` const — which could silently disagree, and the requirement is the full response regardless. Removing truncation leaves nothing to configure, so the field, the const pair and `cap()` all go rather than being reworked into a config-only value. Also fixes a gap the compiler could not catch: `generate_refresh_payment_method_response` assigns proto fields rather than building a struct literal, so the missing `unmasked_connector_response` never raised E0063. `PaymentMethodServiceRefreshResponse` was silently never populated on either the success or error path. Seed only `adyen` in the sample config. The other five lists were written from knowledge of each gateway's response shape and never verified against a live call; shipping guesses as config invites people to trust them. Removed connectors fall into the "no entry" case — every value masked, every key still visible. Verified against the Adyen sandbox: 1474 bytes returned whole with no truncation marker, listed fields visible, 51 additionalData keys present with masked values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three checks were red because local verification was weaker than CI's. - Run Tests: `cargo check --workspace` does not compile test targets, so 16 `PaymentFlowData` literals in razorpay/calida/adyen `test.rs` were never seen. Adding `unmasked_connector_response: None` to each. `--all-targets` is what makes "let rustc enumerate every miss" actually hold. - Clippy: removing `max_bytes` left the manual `Default` identical to what the derive produces, tripping `derivable_impls` under `-D warnings`. Dropped the impl and added `Default` to the derive list. - Spell check: `unparseable` -> `unparsable`, matching how the repo already spells it in kount and affirm rather than adding a `.typos.toml` exception. Verified with CI's own commands: `cargo check --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings` (exit 0), `cargo test --workspace --no-run` (exit 0), `cargo fmt --all -- --check`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI builds the merge of this branch with main, not the branch alone. Four commits landed on main meanwhile — Plaid authenticator, currency conversion, PayPal sender_payment_instrument_id — adding call sites this branch had never seen. That is why CI failed while local checks passed. - merge origin/main - `EventProcessingParams` literal added by main (payments.rs, PaymentMethodToken flow) now carries `connector_response_masking` - 3 `PaymentFlowData` / `MerchantAuthenticationFlowData` literals in the new plaid tests carry `unmasked_connector_response` - trim comments in connector_response_masking.rs from a 23% ratio to 12% (repo norm is 8-10%): design rationale lives in the PR description, not in nine-line doc essays. Section-divider banners dropped — only 12 files in the repo use that style. Kept the non-obvious ones: why array elements inherit the mask decision, why XML whitespace is never masked, why nothing is rebuilt after a patch. Verified with CI's own commands, including the `--all-features` flag missing from earlier rounds: RUSTFLAGS="-D warnings" cargo check --workspace --all-features --all-targets cargo clippy --workspace --all-features --all-targets -- -D warnings (exit 0) cargo test -p integration-tests --all-features --lib --no-run cargo test -p composite-service --all-features --test composite_request_schema_check --no-run cargo fmt --all -- --check Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`is_always_masked` normalises the key (allocating a String) then scans it against 16 constant patterns. It ran first, for every field — including the majority that are not allowlisted and get masked regardless. Swapping the two is behaviour-preserving: the denylist only ever overrides a key the allowlist would have revealed, so for a key absent from the allowlist its verdict never mattered. On the verified Adyen response — 51 additionalData fields, 1 allowlisted — the denylist scan drops from 51 executions to 1. Also sharpens the denylist's documented scope. It covers full PAN, CVV, expiry and credentials; it does not cover truncated values such as cardSummary, last4 or cardBin. Those are not PAN, they appear on receipts, and a connector that needs them for reconciliation can name them in its own list. The previous wording implied broader coverage than the patterns actually provide. Verified against the Adyen sandbox: `expiryDate` placed deliberately in the allowlist still returns "***", proving the denylist still overrides config; the shipped-config baseline is byte-identical to before the change, with no PAN anywhere in the body. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checking every RequestContent variant against the response path found two
defects. The masker reads the pre-preprocessing bytes — what the gateway put on
the wire — which is right for a diagnostic but means it must cope with raw
shapes that 10+ connectors normalise away in preprocess_response_bytes.
Newline-separated bodies could leak. Fiuu replies with `key=value` pairs
separated by newlines, not `&` (see connectors/fiuu.rs:176). serde_urlencoded
splits only on `&`, so the whole body folded into the first pair's value:
key='Status' value='00\nTranID=…\nCardNo=411111xxxxxx1111\nAmount=…'
An operator allowlisting `status` — the natural first choice — would have
revealed that value and everything inside it. Normalising literal newline bytes
to `&` before parsing keeps each pair distinct. Percent-encoded %0A inside a
genuine form value is untouched, so real urlencoded bodies are unaffected.
Verified end-to-end against a mock serving Fiuu's exact shape: the masked body
is now `Status=00&TranID=***&CardNo=***&Amount=***&Domain=***`.
BOM defeated every parser. Authorize.Net prefixes responses with a UTF-8 BOM and
strips it in preprocess_response_bytes — which runs inside handle_response_v2,
after masking. serde_json rejected the BOM and every such response silently
yielded the `_format` stub. Stripping it before `detect` also fixes the
no-Content-Type case, where the BOM made the first meaningful byte 0xEF and
misrouted sniffing to the form parser.
Multipart and binary bodies still yield the size-only stub, deliberately:
RequestContent::FormData is only ever built request-side, and binary cannot be
masked meaningfully.
Adyen unchanged: pspReference, resultCode, refusalReason, merchantReference
visible, 51 additionalData keys, everything else masked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // rejects. The connector strips it in `preprocess_response_bytes`, but that runs inside | ||
| // `handle_response_v2` — after this point. Must precede `detect`: a BOM makes the first | ||
| // meaningful byte `0xEF`, so sniffing would misroute before reaching the `{`. | ||
| let body = body.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(body); |
There was a problem hiding this comment.
there is a existing function for this functionality of removing prefixes i think we can use it
There was a problem hiding this comment.
Agreed on the duplication, though strip_bom_and_convert_to_string (service.rs:1688) can't be called from here:
- it's private to
external-services, anddomain_typescan't depend on that crate — the arrow runs the other way (external-services/Cargo.toml:46); - signature mismatch: it's
&[u8] -> Option<String>, but this point needs&[u8] -> &[u8].detect()sniffs raw bytes andmask_xml/mask_formconsume bytes, so routing throughStringwould force UTF-8 validation plus ato_vec()copy before format detection, and drop non-UTF-8 bodies to the unparsable stub.
Extracted strip_utf8_bom(&[u8]) -> &[u8] into common_utils::bytes_utils instead — both crates already depend on common_utils. Both call sites now go through it, and it strips repeated BOMs to match the trim_start_matches behaviour it replaced, so service.rs is unchanged behaviourally.
| const ALWAYS_MASKED_SUBSTRING: &[&str] = &[ | ||
| "cardnumber", | ||
| "cardnum", | ||
| "accountnumber", | ||
| "cvv", | ||
| "cvc", | ||
| "cvn", | ||
| "expmonth", | ||
| "expyear", | ||
| "expirydate", | ||
| "secret", | ||
| "token", | ||
| "password", | ||
| "signature", | ||
| "apikey", | ||
| ]; | ||
|
|
||
| /// Never revealed, matched **exactly**. `authorization` is here rather than above so it does not | ||
| /// also block `authorizationCode`, which operators legitimately reveal. | ||
| const ALWAYS_MASKED_EXACT: &[&str] = &["authorization"]; | ||
|
|
There was a problem hiding this comment.
I'm not sure we should own this hardcoded denylist — we can't realistically know every sensitive field name across all connectors. One connector calls it pan, another
primaryAccountNumber, another encryptedPaymentData. This list will always be playing catch-up.
Right now the allowlist is static config so it's manageable, but when this becomes dynamic (client-controlled), we should not be logging unmasked_connector_response on
our side at all — just return it in the gRPC response. That way if someone configures a bad allowlist, the unmasked value only shows up in their system, not ours.
There was a problem hiding this comment.
Two parts here, answering them separately.
On the denylist — the premise is right, but it isn't the mechanism doing the work. The default is mask-everything, so an unknown sensitive field name is masked because it was never allowlisted, not because the denylist caught it. ALWAYS_MASKED_SUBSTRING/_EXACT only ever fire on keys an operator explicitly allowlisted, which makes them a backstop against a careless config entry rather than the primary defence. An incomplete list therefore costs nothing the default doesn't already cover, so I'd keep it as-is.
On the logging — taking this now rather than at the dynamic-config milestone, because the containment argument already holds under static config: a typo in the TOML writes to our logs today. Added log_to_span, gated separately from enabled:
[connector_response_masking]
enabled = true # populate the field on the gRPC response
log_to_span = false # ALSO record response.unmasked_body on our spantrue in development.toml, false in sandbox.toml and production.toml. The caller always gets the masked view back on the response; a copy only lands in our own logs where that's explicitly turned on, so a bad allowlist entry stays contained to whoever configured it.
| let Ok(connector) = domain_types::connector_types::ConnectorEnum::from_str(connector_name) | ||
| else { | ||
| return; | ||
| }; |
There was a problem hiding this comment.
add debug log here
There was a problem hiding this comment.
Adding a log here would have papered over a live bug — the comment above this branch is wrong.
connector_name doesn't come from ConnectorEnum. It comes from ConnectorVariant::get_connector_name() (connector_types.rs:403), which stringifies five enums. Three names have no ConnectorEnum counterpart, so from_str fails and the function returns before populating anything:
| Name | Source enum |
|---|---|
Interpayments |
SurchargeConnectorEnum |
Deutschebank |
PayoutConnectorEnum |
Plaid |
AuthenticatorConnectorEnum |
The branch is reachable — set_unmasked_connector_response is implemented for surcharge_types.rs, payouts_types.rs and merchant_authentication_flow_data.rs. And the breakage was inconsistent rather than uniform: Loonio, Paypal, Itaubank, Worldpayxml, Cybersource (payout) and Kount (FRM) collide with ConnectorEnum names and worked fine, so behaviour differed per connector inside the same flow with no signal.
Same root cause hit config load: deserialize_connector_keys also parsed keys with ConnectorEnum::from_str, so deutschebank = "..." in a TOML aborted startup for a perfectly real connector.
Rather than logging the silent exit, I removed the re-parse. Ingress already validates the name per flow family (connector_variant_from_metadata, metadata.rs:113 — x-connector against ConnectorEnum, x-payout-connector against PayoutConnectorEnum, and so on), so the string arriving here is already canonical. It's now used directly as a lookup key, and config-load validation accepts a name that any of the five enums recognises. A lookup miss degrades to the empty allowlist — which is already the correct default for an unconfigured connector — so there's no failure branch left to log.
Verified: with interpayments/deutschebank/plaid as config keys the server boots; paysafee still aborts with unknown connector `paysafee` .
… re-parse Three review comments on #2050. The middle one turned out to sit on a live bug. record_unmasked_connector_response re-parsed an already-validated connector name against the wrong authority. Ingress resolves a connector per flow family, each against its own enum — x-connector via ConnectorEnum, x-payout-connector via PayoutConnectorEnum, and so on (metadata.rs:113) — and rejects an unknown name with InvalidDataFormat before any connector call. Masking then asked "is it a payment connector?" when the only question it needed answered was "what is its name?", and treated the mismatch as absence: let Ok(connector) = ConnectorEnum::from_str(connector_name) else { return; }; Three ingress-valid connectors have no ConnectorEnum counterpart, so the field was silently None for them: Interpayments (surcharge), Deutschebank (payout), Plaid (authenticator). The breakage was partial rather than uniform — Loonio, Paypal, Itaubank, Worldpayxml, Cybersource (payout) and Kount (FRM) collide with ConnectorEnum names and worked — so behaviour differed per connector inside the same flow with no signal. The comment above the branch claimed it always round-trips; it does not. Same root cause reached config load: deserialize_connector_keys also parsed keys with ConnectorEnum::from_str, so `deutschebank = "..."` aborted startup naming a perfectly real connector. Rather than logging the silent exit, the re-parse is gone. connector_keys is keyed by the snake_case name, which is canonical by the time it arrives, and config-load validation accepts any name the five enums recognise. A lookup miss degrades to the empty allowlist — already the right outcome for an unconfigured connector — so no failure branch remains. hyperswitch::Connector was considered as a single authority and rejected: it omits 15 UCS connectors including Kount, and is a pinned git snapshot. Verified against the built binary: with interpayments/deutschebank/plaid as config keys the server boots; `paysafee` still aborts with "unknown connector `paysafee`". Also in this commit: log_to_span gates the span record independently of enabled, so the caller is sent unmasked_connector_response without a copy being retained in our logs. The containment argument holds under static config too — a typo in the TOML writes to our logs today. true in development, false in sandbox and production. strip_utf8_bom moves to common_utils::bytes_utils, backing both the masker and strip_bom_and_convert_to_string. The existing helper could not be reused in place: it is private to external-services, domain_types cannot depend on that crate, and it returns Option<String> where the masker needs &[u8] -> &[u8] before detect() sniffs raw bytes. Repeated BOMs are still stripped, so service.rs is unchanged behaviourally. The const denylist stays. Default is mask-everything, so an unknown sensitive field is masked because it was never allowlisted; the denylist only fires on keys an operator explicitly allowlisted, making it a backstop rather than the primary defence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // Selectively-masked view of the connector response: every key preserved, | ||
| // values masked unless the connector's configured list names them. Already | ||
| // sanitized, hence `string` and not `SecretString`. | ||
| optional string unmasked_connector_response = 25; |
There was a problem hiding this comment.
all these string in proto are masked_connector_response right?
There was a problem hiding this comment.
Correct, and the name was backwards — renamed to masked_connector_response in 709b8509.
The field holds the connector reply with every value masked (***) except the ones an operator allowlisted for that connector, so unmasked_ promised the opposite of what it carries. Worse given the neighbour: these structs already have raw_connector_response, which is the verbatim unmasked body. Two fields, raw_ and unmasked_, and the safe one was the one whose name promised no masking.
Pure identifier rename, no behavioural change — 260 occurrences plus 5 of the response.unmasked_body span field, across 23 files:
unmasked_connector_response -> masked_connector_response
unmasked_body -> masked_body
Proto tags are untouched, so the wire format is unchanged and only the generated accessor names move. Not a compatibility break either, since these fields don't exist on main — this PR introduces them.
Left alone deliberately: raw_connector_response (394 occurrences — the genuinely unmasked sibling), unmasked_headers (unrelated log config), and mask_connector_response / ConnectorResponseMaskingConfig / MASKED, which already describe masking correctly.
One side effect worth flagging: this also turns SDK Tests green, but by luck rather than by fixing anything. That job was failing because protoc emits every payment.proto message into a single outer class (java_multiple_files is unset) and the generated Payment.java hit 20,973,967 bytes against the Kotlin compiler's embedded IntelliJ cap of 20,971,520 — over by 2,447 bytes, surfacing as FileTooBigException → "Internal compiler error". The shorter identifier reclaims 3,006 bytes, landing ~559 bytes under. main alone sits ~1% below the same ceiling and arduino/setup-protoc@v3 is unpinned in ci.yml, so this will recur. Pinning protoc / raising kotlin.daemon.jvmargs is worth doing separately on main.
…r_response Review feedback from @jarnura on payment.proto: the field name is backwards. It carries the connector's reply with every value masked ("***") except those an operator allowlisted per connector — the masked view, not an unmasked one. Read literally, "unmasked_connector_response" promises masking has been removed, which is the opposite of what it holds. That mattered more than usual because of what sits beside it. The same structs already carry raw_connector_response, which IS the verbatim unmasked body. So the file offered a reviewer two fields, `raw_` and `unmasked_`, and the safe one was the one whose name promised no masking. Anyone reasoning about PII exposure from field names alone would have picked wrong. Pure identifier rename, no behavioural change: unmasked_connector_response -> masked_connector_response (260) unmasked_body -> masked_body (5) Proto field tags are untouched, so the wire format is unchanged; only the generated accessor names move. Not a compatibility break — these fields do not exist on main, they are introduced by this PR. Deliberately left alone: raw_connector_response (394 occurrences, the genuinely unmasked sibling), unmasked_headers (11, unrelated log config), and mask_connector_response / ConnectorResponseMaskingConfig / MASKED, all of which already describe masking correctly. Incidentally unblocks SDK Tests, which should not be relied on. That job fails because protoc emits every payment.proto message into one outer class (java_multiple_files is unset) and the generated Payment.java reached 20,973,967 bytes against the Kotlin compiler's embedded IntelliJ cap of 20,971,520 — over by 2,447 bytes, surfacing as FileTooBigException -> "Internal compiler error". The shorter identifier reclaims 3,006 bytes across every generated accessor, constant and descriptor string, landing ~559 bytes under the cap. That is a coincidence, not a fix: main alone sits ~1% below the same ceiling, and arduino/setup-protoc@v3 is unpinned in ci.yml so generated size drifts upward with each protoc release. Pinning protoc and raising kotlin.daemon.jvmargs remains open, and belongs on main rather than buried in a review-feedback PR. Verified: zero residual occurrences, guard-string counts unchanged, cargo check --workspace --all-targets / clippy / fmt clean, test_config_override green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| fn detect(content_type: Option<&str>, body: &[u8]) -> Option<Format> { | ||
| if let Some(content_type) = content_type { | ||
| let lowered = content_type.to_ascii_lowercase(); | ||
| if lowered.contains("json") { | ||
| return Some(Format::Json); | ||
| } | ||
| if lowered.contains("xml") || lowered.contains("soap") { | ||
| return Some(Format::Xml); | ||
| } | ||
| if lowered.contains("x-www-form-urlencoded") { | ||
| return Some(Format::Form); | ||
| } | ||
| } | ||
|
|
||
| match body.iter().find(|byte| !byte.is_ascii_whitespace()) { | ||
| Some(b'{' | b'[') => Some(Format::Json), | ||
| Some(b'<') => Some(Format::Xml), | ||
| Some(_) => Some(Format::Form), |
There was a problem hiding this comment.
Anything that isn't JSON/XML/SOAP-shaped falls through to Format::Form, and mask_form round-trips arbitrary bytes as a single (whole_body, "") pair — so the body comes back out as a key, unmasked. A declared text/plain lands here too, since the Content-Type branch above doesn't recognise it.
"Transaction declined for card 4111111111111111" -> "Transaction+declined+for+card+4111111111111111=***"
"1,4111111111111111,SUPERSECRET" -> "1%2C4111111111111111%2CSUPERSECRET=***"
The {"_format":"unparsable"} stub never fires because mask_form doesn't fail on arbitrary bytes. Can detect return None when neither the declared Content-Type nor the sniff matches a structured format?
There was a problem hiding this comment.
Confirmed, and the stub was unreachable for the whole Form branch — serde_urlencoded never fails on arbitrary bytes, so mask_form always returned Some.
Both your examples reproduced exactly as written; they're now tests (a_plain_text_body_is_not_re_emitted_as_a_form_key, a_csv_shaped_body_is_not_re_emitted_as_form_keys, plus a binary body).
On where to put the check — I went one level lower than detect. A detect-only change still routes a declared application/x-www-form-urlencoded to the leaking path, and a connector declaring that header while returning something else is exactly the case worth catching. is_pair_shaped now gates mask_form, after newline normalisation so Fiuu's bodies are judged on their real pairs:
for segment in body.split(|byte| *byte == b'&') {
if segment.is_empty() { continue; } // trailing or doubled `&`
match segment.iter().position(|byte| *byte == b'=') {
None | Some(0) => return false, // no `=`, or no key before it
Some(_) => saw_pair = true,
}
}Non-form bodies now fall to {"_format":"unparsable","_bytes":N}. The sniff in detect stays, so a genuine form body with a missing Content-Type still works.
One trade-off worth naming: it's all-or-nothing, so a real form body with a valueless segment (a=1&flag&b=2) is stubbed whole rather than partly emitted. No gateway I know of sends that shape and losing a diagnostic is the cheaper failure, but say the word if you'd rather it degrade per-segment.
Fixed in d2eeb6a.
| serde_json::to_string(&Masked { | ||
| value: &value, | ||
| keys, | ||
| mask: false, |
There was a problem hiding this comment.
The root is seeded mask: false, so a body with no key to gate on goes out in the clear:
["4111111111111111","x"] -> ["4111111111111111","x"]
"4111111111111111" -> "4111111111111111"
Both reach here — [ sniffs as JSON, and a bare string with Content-Type: application/json routes here too. Seeding the root mask: true fixes it; objects re-decide per key one level down, so nothing correctly revealed today regresses.
There was a problem hiding this comment.
Confirmed both, and the second one is worse than it looks: the seed inverts the fail-closed premise the whole design rests on, and it's invisible on the one shape every existing test used. An object re-decides per key immediately, so {...} bodies masked correctly no matter what the root was seeded with.
Seeded true in mask_json. Your two cases and the sniffed variant ([ with no Content-Type) are now a_top_level_array_of_scalars_is_masked, a_bare_top_level_scalar_is_masked and a_top_level_array_is_reached_by_sniffing_too.
Fixed in d2eeb6a.
| Value::Array(items) => { | ||
| let mut state = serializer.serialize_seq(Some(items.len()))?; | ||
| for value in items { | ||
| state.serialize_element(&Self { | ||
| value, | ||
| keys: self.keys, | ||
| mask: self.mask, |
There was a problem hiding this comment.
Array elements inherit mask: self.mask, so an allowlisted key passes false down to every element — which contradicts the invariant in the comment just above. With success in the Adyen list:
{"success":["4111111111111111"]} -> {"success":["4111111111111111"]}
Objects do behave as documented. Passing mask: true to elements restores the invariant; arrays under a masked key are unaffected.
There was a problem hiding this comment.
Confirmed, and taking it as you describe. The invariant is the right one to hold to: elements carry no key, so an operator can never name one — allowing success should reveal the scalar it names, not everything nested beneath it. Elements now always get mask: true.
Worth noting the two array rules pull in opposite directions and both are now pinned:
allowlist = [success]
{"success": true} -> {"success": true}
{"success": ["4111111111111111"]} -> {"success": ["***"]} <- this comment
{"tags": ["4111111111111111"]} -> {"tags": ["***"]} <- the original motivation
The first inheriting case was the reason mask was threaded into elements at all; it stays covered, since true is now passed unconditionally rather than inherited.
Also rewrote the Masked doc comment — it described mask as "carries the parent key's decision so array elements inherit it", which documented the bug rather than the intent. It now says mask is the decision for a value with no key of its own, cleared only by an allowlisted object entry.
Fixed in d2eeb6a.
| } | ||
| } | ||
| // Declaration, comments, processing instructions, doctype: structural, copied as-is. | ||
| other => { |
There was a problem hiding this comment.
This catch-all forwards Comment, DocType and PI verbatim, so a gateway that echoes the request inside a comment leaks it:
<r><!-- pan 4111111111111111 --><a>b</a></r> -> <r><!-- pan 4111111111111111 --><a>***</a></r>
Decl is structural and fine to copy through; these three aren't. Explicit arms that drop them, or emit *** in their place?
There was a problem hiding this comment.
Confirmed — reproduced verbatim, including inside <!DOCTYPE>'s internal subset and a PI:
<r><!-- pan 4111111111111111 --><a>b</a></r>
<!DOCTYPE r [<!ENTITY pan "4111111111111111">]><r><a>b</a></r>
<r><?echo 4111111111111111?><a>b</a></r>
Went with dropping rather than emitting ***, except for comments where the placeholder is cheap and keeps the signal that a comment was there. Decl passes through as you say.
The more useful part of the change is the default. It was other => write_event(other) — forward anything unrecognised — which is why three variants leaked without anyone choosing that. It's now _ => {}, so an event this match doesn't know is dropped. On the next quick-xml bump (pinned at 0.31.0) a new variant fails closed instead of silently joining the passthrough.
Fixed in d2eeb6a.
| @@ -0,0 +1,436 @@ | |||
| //! Builds `masked_connector_response`: the connector's reply with every key preserved and every | |||
There was a problem hiding this comment.
b4431bc removed the unit tests and there's no #[cfg(test)] block, so Run Tests is green on 436 lines nothing exercises. Each leak I've flagged here reproduces in a few lines — minimum before merge:
- keyless top-level JSON: array of scalars, bare scalar
- arrays under an allowed key and under a denied key
text/plain, CSV-shaped and binary bodies- XML comments and DOCTYPE
- BOM-prefixed and newline-separated form bodies, both special-cased in
mask_formand neither guarded - an allowed key whose value is an object, to pin the invariant that holds
There was a problem hiding this comment.
Taken in full. b4431bc is restored and ported — the config map is keyed by Box<str> now rather than ConnectorEnum, max_bytes is gone, and mask_connector_response takes &str — plus the cases you listed. 40 tests, and the toml dev-dependency comes back with them.
I wrote them before the fixes, against the code as it stood. 11 failed, one per bypass — which is the part of your comment that actually mattered, since a test written after a fix can't distinguish "passes because the fix works" from "passes because it never tested the leak". The failure output printed the PAN in the clear in every case:
a_bare_top_level_scalar_is_masked left: "\"4111111111111111\""
a_top_level_array_of_scalars_is_masked left: "[\"4111111111111111\",\"x\"]"
a_top_level_array_is_reached_by_sniffing_too
scalars_in_an_array_under_an_allowed_key_are_masked_too left: "{\"success\":[\"4111111111111111\"]}"
a_plain_text_body_is_not_re_emitted_as_a_form_key Transaction+declined+for+card+4111111111111111=***
a_csv_shaped_body_is_not_re_emitted_as_form_keys 1%2C4111111111111111%2CSUPERSECRET=***
a_declared_form_body_that_is_not_form_shaped_yields_the_stub 4111111111111111=***
a_binary_body_is_not_re_emitted_as_a_form_key
xml_comments_do_not_carry_their_content_through <r><!-- pan 4111111111111111 --><a>b</a></r>
xml_doctype_does_not_carry_its_content_through <!DOCTYPE r [<!ENTITY pan "4111111111111111">]>...
xml_processing_instructions_do_not_carry_their_content_through <r><?echo 4111111111111111?><a>b</a></r>
The other 29 passed unchanged, which is the useful control — the ported coverage was not silently rewritten to fit the new behaviour.
Everything on your list is covered. Two notes on how:
- Assertions are on absence of the PAN, not only on output shape.
assert_eq!pins how a value is spelt;assert!(!out.contains(PAN))pins the property. Both, where the shape is worth fixing. - Beyond your list: an allowlisted key holding a scalar (so the array rule can't over-mask), and config load accepting a name from each of the five enums.
BOM-prefixed and newline-separated form bodies both hold — a_bom_prefixed_body_is_still_parsed and a_newline_separated_form_body_is_masked_pair_wise.
Added in d2eeb6a.
| /// (`ucs_interface_common::metadata::connector_variant_from_metadata`). No single enum spans all | ||
| /// five, so validating against one would reject real connectors: `interpayments`, `deutschebank` | ||
| /// and `plaid` have no [`ConnectorEnum`] counterpart. | ||
| fn is_known_connector(name: &str) -> bool { |
There was a problem hiding this comment.
is_known_connector accepts AuthenticatorConnectorEnum, but the lookup key comes from ConnectorVariant::get_connector_name() (connector_types.rs:352) and ConnectorVariant has no authenticator arm — so an authenticator entry passes startup validation and then never matches. The config comments still tell operators plaid is valid. Drop it from this check and the comments, or add the arm to get_connector_name().
There was a problem hiding this comment.
Pushing back on this one — ConnectorVariant does have an authenticator arm, so the lookup resolves.
// connector_types.rs:354
pub enum ConnectorVariant {
Payment(ConnectorEnum),
Surcharge(SurchargeConnectorEnum),
Payout(PayoutConnectorEnum),
Frm(FrmConnectorEnum),
Authenticator(AuthenticatorConnectorEnum), // :359
}
// connector_types.rs:403
pub fn get_connector_name(&self) -> String {
match self {
...
ConnectorVariant::Authenticator(conn) => conn.to_string(), // :409
}
}It arrived with #1975 (423af1e0d, "add Plaid as authenticator connector"), which is an ancestor of origin/main and entered this branch in 0cd38c022 on Aug 4 at 16:52 IST — before your review, so it isn't a rebase artefact. Likely a stale local checkout; the line you cited (:352) doesn't match either the pre- or post-#1975 numbering.
The flow also terminates somewhere real: merchant_authentication_flow_data.rs:74 implements set_masked_connector_response, so an authenticator response does reach masking rather than resolving to a key nothing ever queries.
So is_known_connector keeps AuthenticatorConnectorEnum and the config comments keep plaid. Happy to be shown otherwise if you're seeing something I'm not — but on this HEAD, dropping it would reject a connector that works.
The adjacent claim underneath it is worth pinning, so config_accepts_a_name_from_any_connector_enum now loads one name from each of the five enums (adyen, interpayments, deutschebank, kount, plaid) and asserts each resolves — that's what would actually catch this class of drift, in either direction.
| /// Validate map keys via `FromStr`, not the serde derive: strum's `snake_case` applies to | ||
| /// `FromStr` only, and the config crate lowercases env-var keys. Same route as | ||
| /// `WebhookSourceVerificationCall`. | ||
| fn deserialize_connector_keys<'de, D>( |
There was a problem hiding this comment.
#[strum(serialize_all = "snake_case")] on ConnectorEnum (connector_types.rs:60) applies to the derived Display as well as EnumString — which is why get_connector_name() gives adyen and the seeded config key resolves at all. Validating via FromStr is still right; the reasoning here just has it backwards.
There was a problem hiding this comment.
Correct — the conclusion was right and the reasoning was wrong, which is the worse kind of comment to leave behind. #[strum(serialize_all = "snake_case")] (connector_types.rs:60) governs the derived Display alongside EnumString, which as you say is precisely why get_connector_name() yields adyen and the seeded key resolves.
What strum doesn't reach is serde, and that's the actual reason for the manual route: the connector enums carry no #[serde(rename_all)], so their serde derive would demand Adyen, while TOML keys and the config crate's env-var keys both arrive lowercased. Comment rewritten to say that.
Fixed in d2eeb6a.
| [connector_response_masking] | ||
| enabled = true |
There was a problem hiding this comment.
enabled = true here and in sandbox.toml, gated independently of return_raw_connector_data, means the masked view reaches callers from the first deploy — which makes the bypasses I've flagged on connector_response_masking.rs live rather than latent. Can we default it false until those are closed and covered? The struct Default is already false, so it's config-only.
The comment block above also promises PAN/CVV/expiry stay masked regardless — true for keyed fields, not for the unkeyed paths.
There was a problem hiding this comment.
Taking it. enabled = false in both sandbox.toml and production.toml, development.toml stays true. Config-only, as you note — the struct Default was already false.
I'd have argued the containment case is weaker now that the bypasses are closed in this same PR, but that's the wrong way round: the reason to ship it off is that turning it on returns connector response bytes to the caller, so a deployment should opt in once it has chosen its key lists — independent of whether any particular bypass is open. Added that as the comment above the flag.
On the comment block — you're right that it was a claim about keyed fields dressed up as an unconditional one. Rewritten to say what naming a key actually grants, and to name the unkeyed case explicitly rather than leave it implied:
# Naming a key here reveals only that key's own value: an object below it is re-decided key by
# key, and an array below it stays masked, since its elements have no key you could name.
# A key whose name looks like a full PAN, CVV, expiry or credential stays masked even if listed.
# Truncated values (cardSummary, last4, cardBin) are not covered — name them if needed.
# A body that is not JSON, XML or form-encoded has no keys to gate on, so it is replaced
# wholesale by a stub carrying only its size.
Applied to all three config files. Fixed in 52de180.
Every value is masked unless the connector's allowlist names its key. Four paths
reached a value with no key in scope and defaulted to revealing it.
- `mask_json` seeded the root `mask: false`, so a body with nothing to gate on went
out in the clear: `["4111111111111111"]` and a bare `"4111111111111111"` were
returned verbatim. Seeded `true`; an object re-decides per key immediately, so
nothing correctly revealed today changes.
- Array elements inherited the parent key's decision, so one allowlisted key
revealed every scalar beneath it — `{"success":["4111111111111111"]}` with
`success` listed. This contradicted the invariant the object arm upholds:
elements carry no key, so an operator can never name one. Elements now always
mask.
- `detect` falls through to `Format::Form` for anything not JSON/XML-shaped, and
`serde_urlencoded` never fails on arbitrary bytes — a segment with no `=` parses
as `(whole_segment, "")`. Since keys are emitted verbatim by design, a
`text/plain` decline message came back out as its own key:
"Transaction declined for card 4111111111111111"
-> "Transaction+declined+for+card+4111111111111111=***"
The `{"_format":"unparsable"}` stub was therefore unreachable on this branch.
`is_pair_shaped` now gates `mask_form`, which sends non-form bodies to the stub.
Placed in `mask_form` rather than `detect` so a *declared*
`x-www-form-urlencoded` body that isn't pair-shaped is caught too.
- `mask_xml`'s catch-all forwarded `Comment`, `PI` and `DocType` verbatim, so a
gateway echoing the request into a comment leaked it. `Decl` still passes through
(version/encoding), a comment keeps its presence but not its content, and the
default is now to drop rather than forward — so a variant a future `quick-xml`
adds fails closed.
Restores the test module dropped in b4431bc, ported to the current API (`Box<str>`
keys, no `max_bytes`, `&str` connector name) and extended to cover each bypass
above plus the invariants that already held. All 11 new tests fail on the parent
commit. Also corrects the `deserialize_connector_keys` doc comment: strum's
`snake_case` governs `Display` as well as `FromStr`; what it does not reach is
serde, which is the actual reason for the manual route.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ment `enabled = true` in sandbox.toml and production.toml put the feature live on the first deploy, with no chance to review the key lists first. Turning it on returns connector response bytes to the caller, so each deployment should opt in. Both go `false`, matching the struct `Default`; development.toml stays `true` so the path is exercised. Config-only. Also corrects the comment block above the lists. It promised that PAN, CVV, expiry and credentials "stay masked regardless of what is listed here", which is a claim about keyed fields only. It now states what naming a key actually grants — that key's own value, not the subtree beneath it — and that a body matching no structured format is replaced by a stub carrying only its size. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
hey @shuklatushar226 one more thing use patch ignore ( if this feature has to be not supported at runtime because then user can use config override on this) |
A runtime `enabled` flag can be flipped by a misconfiguration, an env var or a config patch. This code turns raw gateway bytes into a string handed back to the caller, so the guarantee should be structural: a build that does not opt in does not contain it. Adds `connector-response-masking`, off by default, chained domain_types -> external-services -> ucs_env -> grpc-server. Under the gate: the `connector_response_masking` module (with quick-xml and serde_urlencoded, which nothing else in domain_types uses, demoted to optional deps), the `Config` section, the `EventProcessingParams` field, and `record_masked_connector_response` with its two call sites. The runtime flag is kept alongside it — the build decides whether the code exists, config still lets an operator switch it off without a redeploy. The boundary stops at the proto: prost generates the field unconditionally, so it and the inert `Option<String>` on the flow-data structs stay in every build and are simply always unset when the feature is off. The release image is unaffected: the Dockerfile already pins its feature list, so it excludes masking by construction. CI's nextest invocations pass the feature so the module's 40 unit tests keep running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`log_to_span` promised the caller could be sent `masked_connector_response`
"without a copy being retained in our own logs". It did not hold: with the flag
off, the masked view still reached the logs 3 times per request.
Unlike its sibling `raw_connector_response` — declared `SecretString`, which
build.rs maps to `hyperswitch_masking::Secret<String>` whose `Debug` prints a
type stub — this field is a plain `string`, so it has no type-level masking to
fall back on and every generic response log printed it in full.
Two sinks carried it, both in grpc-server::utils:
- `response_body`, recorded via `field::debug` of the whole response struct.
- the gRPC event payload, which `emit_event_with_config` logs as JSON on every
request regardless of `events.enabled`. This one is the only sink for the
payout endpoints, which carry no `#[tracing::instrument]` at all.
Both now go through `response_for_logging`, which serializes with
`masked_serialize` — matching what the request side already does — and drops the
field while the flag is off. When the flag is on, behaviour is unchanged.
`response_body` consequently changes from Rust-`Debug` to masked JSON: removing
one key requires a structured value. That aligns it with `request_body` on the
same golden log line, and means response logging now honours `Secret` masking
through serde rather than relying on each field's `Debug`.
Verified against the Adyen sandbox: with log_to_span off the field is absent from
every log line while still returned to the caller (1379 bytes), including on the
4xx path; with it on, the previous shape is preserved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Euler needs the masked connector reply off the event stream, not only on the
gRPC response. Nothing reached Kafka before: the previous commit stripped the
field from the `Event` itself to keep it out of the logs, which removed it from
the published payload too.
That was the wrong cut. `emit_event_with_config` builds one payload and sends it
to both `tracing::info!` and `publish_event_to_kafka`, so keeping a field out of
the logs by removing it from the event necessarily withholds it from consumers.
The two copies have to diverge instead.
- Attach the masked view to the connector-call event in
`record_masked_connector_response`, where it is produced, next to the request
and response it describes. `additional_fields` is flattened, so it lands as a
top-level `masked_connector_response` key on the `connector_events` topic.
Attached unconditionally: delivery is the point of the field and must not
depend on whether we log it.
- Add `emit_event_with_config_redacting`, which omits given keys from the
logged copy while publishing the payload intact. `emit_event_with_config`
delegates to it, so no existing call site changes.
- Removal is recursive. The key sits at a different depth on each event — top
level on the connector event, under `response_data` on the gRPC event, and
nested again inside `event_content.content` for webhook responses. This also
closes a latent gap in the previous commit, whose top-level-only strip would
have missed the nested case; nothing leaked only because the webhook path
hard-codes the field to None.
`log_to_span` therefore returns to meaning what its name says: UCS logs only. It
no longer decides what downstream consumers receive, and the doc comment now
says so — containment is within UCS, not end to end, since the published record
is a retained copy we no longer control.
Verified against the Adyen sandbox: with log_to_span off the field appears in no
log line yet is still returned to the caller; with it on, the connector event
carries it at top level. Six unit tests cover recursive removal, the borrow-when-
unmatched path, and the property this rests on — that redaction leaves the
published value untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Description
Adds
unmasked_connector_response: a sibling toraw_connector_responsecarrying the same gateway reply with every key preserved and every value masked unless that connector's configured list names it.It is a plain
String, not aSecret— it is already sanitized, and wrapping it would reproduce the bug being fixed. The body is emitted whole; there is no truncation.The whole feature sits behind a Cargo feature,
connector-response-masking, which is off by default. A build that does not opt in does not contain the masking code at all, so no configuration — file, env var or runtime patch — can make it emit connector response content. The release image does not enable it;config/development.tomlturns it on for local work.Motivation and Context
raw_connector_responseisOption<Secret<String>>(domain_types/src/connector_types.rs), somasked_serializerenders the whole body as one placeholder — literally*** alloc::string::String ***. That leaves two options, both useless:And
response.bodyon the outgoing span only shows the typed struct, so any field a transformer did not model is invisible. When a gateway returns something unexpected, it cannot be diagnosed from logs at all.Design notes
Masking happens during serialization, not by mutating the parsed tree. The obvious implementation — walk the tree and overwrite masked values — cannot see scalars inside arrays, because they reach the walker with no key in scope.
{"tags":["4111111111111111"]}would have gone out in the clear. Carrying the parent-key decision into elements makes that leak impossible by construction, and also drops one pass over the tree plus an allocation per masked field.Output keeps the input format. JSON in / JSON out; XML in / XML out; form-encoded in / form-encoded out. XML is copied event by event rather than round-tripped through
serde_json::Value—quick_xml::defolds attributes into@namekeys and text into$textkeys, so a round trip emits literal<$text>elements and loses namespaces, the root name, and the attribute/element distinction.Config is per-connector only, with no global list. A field name safe on one gateway is not necessarily safe on another — a global list would make
authCodesafe everywhere because it is safe on one connector.Keyed by
ConnectorEnum. An unknown connector name aborts startup naming the bad key rather than silently masking everything:Keys parse through
FromStrrather than the serde derive, because#[strum(serialize_all = "snake_case")]governsFromStr/Displayonly, and the config crate lowercases env-var keys —CS__…__CONNECTOR_KEYS__ADYENarrives asadyenand could never matchAdyen. Same routeWebhookSourceVerificationCalltakes.Gated by its own
enabledflag, independent ofreturn_raw_connector_data. Production can keep raw capture off and still get the safe view — that is the main reason the feature exists.Gated at compile time as well, not only at runtime. A runtime flag can be flipped by a misconfiguration, an env var or a config patch. This code turns raw gateway bytes into a returnable string, so the guarantee should be structural rather than procedural. Under the feature gate: the
connector_response_maskingmodule (andquick-xml/serde_urlencoded, which nothing else indomain_typesuses, so they become optional deps), theConfigsection, theEventProcessingParamsfield, andrecord_masked_connector_responsewith its two call sites. The feature chainsdomain_types → external-services → ucs_env → grpc-server, so enabling it on the server is enough.The runtime
enabledflag is deliberately kept alongside it: the build decides whether the code exists, config still lets an operator switch it off without a redeploy, and the per-connector allowlists need config regardless.The boundary stops at the proto.
prostgeneratesmasked_connector_responsefrom the.protounconditionally, so every response literal has to supply a value in any build. That field and the inertOption<String>on the nine flow-data structs therefore remain compiled in and are simply always unset when the feature is off. Gating them would mean ~90 more#[cfg]lines, a build-time proto rewrite, and the same tax on every future flow-data struct — for 24 inert bytes.Additional Changes
API contract —
optional string masked_connector_responseadded to 23 response messages (21 inproto/payment.proto, 2 inproto/frm.proto), each at the next free tag. Additive and backward compatible; no tag is reused. Note the type isstring, notSecretString, unlike its neighbour.Configuration — new
[connector_response_masking]section inconfig/development.toml,config/sandbox.toml,config/production.toml:Only
adyenis seeded because it is the one entry verified against a live sandbox call — it is an example, not a recommended set.Env-overridable per connector without a code change:
No new dependencies beyond moving
quick-xml(already in the lockfile) intodomain_types.Build configuration — a new Cargo feature
connector-response-masking, declared ondomain_types,external-services,ucs_envandgrpc-server, and absent from every default build:The
Dockerfileis unchanged — it already pins--features kafka,connector-request-kafka,otel, so the release image excludes masking by construction; a comment there records that the omission is deliberate. CI's twocargo nextest runinvocations now pass the feature, or the module's 40 unit tests would silently stop running;cargo clippy --all-featuresalready covered the feature-on build.How did you test it?
Unit tests plus a manual end-to-end run against the real Adyen sandbox. The feature gate adds a second axis: every behaviour is checked with the feature both off and on.
40 unit tests in
connector_response_masking.rs. The module removed inb4431bcis restored and ported to the current API, and extended to cover the four masking bypasses found in review. Those 11 new tests were written before the fixes and each one failed on the parent commit, printing the PAN in the clear — so they test the leak, not just the fix.additionalDatakeys present with***cardSummary=1111raw,***maskedRETURN_RAW_CONNECTOR_DATA=false+ masking on…CONNECTOR_KEYS__ADYEN=pspreferencepaysafeein TOML4111…/ CVC anywhere in logsCoverage spans: keyed masking and the denylist override; the invariant that an allowed key never reveals a subtree (objects re-decide per key, arrays stay masked); bodies with no key to gate on (root array, bare scalar,
text/plain, CSV, binary); XML text, attributes,CData, comments, DOCTYPE and PIs; form bodies that are BOM-prefixed, newline-separated or not pair-shaped; and config load accepting a name from each of the five connector enums while rejecting a typo.cargo check --workspace(no warnings),cargo fmt --all --checkandcargo clippyon the touched crates are clean.Compile-time gate, end to end
Live sandbox calls through a locally-run server, feature off and on:
development.tomlas shipped (enabled = true, adyen allowlisted)CHARGED;masked_connector_responseabsentENABLED=true,LOG_TO_SPAN=trueand an allowlistCHARGED; still absent, 0response.masked_bodyspan recordsdevelopment.tomlallowlist***ENABLED=falseauthCode,cardSummary,cardBin,expiryDateexpiryDatestays***(denylist beats the operator's list); the TOML keys go dark, confirming the override replaces rather than merges<errorCode>4025</errorCode>revealed, siblings***, output still XMLstatus,tranIDallowlistedTranID=31530063revealed,VrfKeyandAmount***, output still form-encodedThe two
offrows are the point of the change: the same env overrides that drive theonrows cannot make the default build emit anything. Elavon's sandbox credentials were rejected (4025), so the XML path was exercised against its error body rather than a success body; Fiuu PSync covers the newline-separated form shape thatmask_formnormalises.Build and lint, both configurations:
cargo check -p grpc-servercargo check --workspace --all-targetscargo clippy --all-targetson the touched cratescargo test -p domain_types connector_response_maskingcargo tree -p domain_types --depth 1quick-xml+serde_urlencodedpresentcargo fmt --all --checkis clean.Notes for reviewers
constdenylist (cardnumber,cvv,token, …) overrides config so a careless entry cannot expose a PAN.authorizationis deliberately exact-match rather than substring, so it does not permanently blockauthorizationCode— a routine field operators will want visible.generate_refresh_payment_method_responseassigns proto fields rather than building a struct literal, so a missing field raises noE0063. That path was silently unpopulated until an unused-variable warning surfaced it; worth a second pair of eyes on whether any similar assignment-style site was missed (grep -rn '\.raw_connector_response = ').<a>x<b>y</b>z</a>) over-masks the trailing text, since the enclosing element is no longer tracked after</b>. Conservative direction, and mixed content does not occur in payment APIs.VerifyRedirectResponsesets the field toNone: it is assembled from a redirect verification rather than a connector HTTP call, so no masked body exists.{"_format":"unparsable","_bytes":N}rather than emitted. This is all-or-nothing: a real form body with a valueless segment (a=1&flag&b=2) is stubbed whole. No gateway is known to send that shape.***), but if a pathological response ever floods the span/gRPC/Kafka payloads, a cap can be reintroduced as a config-only field.external-services/connector-response-maskingwithoutgrpc-server's own feature gives a missing-field error at the 18EventProcessingParamsliterals. The forwarding chain keeps every normal path and--all-featuresconsistent; only a hand-crafted feature combination hits it.#[cfg]on those struct-expression fields also means a future call site that forgets the gate fails to compile rather than drifting silently.🤖 Generated with Claude Code